Creating a Custom GitHub Action Using Go
Learn how to create custom GitHub Actions using Dockerfiles and images in Go.
We'll cover the following
In this lesson, we will extend our work by turning the tweeter command line into a GitHub Action. This will allow anyone on GitHub building automation to use tweeter to tweet from their own pipeline. Furthermore, we’ll use our tweeter action to tweet when we release new versions of tweeter by extending the release job to use our new action.
In this lesson, we will learn the basics of authoring GitHub Actions. We will create a custom GitHub Action using Go. We will then optimize the start-up time of our custom action by creating a container image.
Basics of custom actions#
Custom actions are individual tasks that wrap a collection of related tasks. Custom actions can be executed as individual tasks in workflows and can be shared with the GitHub community.
Types of actions#
There are three types of actions: container, JavaScript, and composite actions. Container-based actions use a Dockerfile or a container image reference as the entry point, the starting point of execution for the action, and are useful if we want to author an action in anything but JavaScript or existing actions. Container-based actions offer flexibility in customizing the execution environment of an action, but it comes at the cost of start-up time. If a container-based action depends on a large container image or a slow-building Dockerfile, then the action start-up time will be adversely affected. JavaScript actions can
run directly on the runner machine and are the native expression of an action. JavaScript actions start up quickly and can leverage the GitHub Actions Toolkit, a set of JavaScript packages to make creating actions easier. Composite actions are a collection of steps within a wrapper action. They enable an author to combine a set of disparate steps into a higher-order behavior.
Action metadata#
To define an action, we must create an action.yaml file in a GitHub repository. If the action is to be shared publicly, the action.yaml file should be created in the root of the repository. If the action is not to be shared publicly, it is recommended to create the action.yaml file in ./.github/{name-of-action}/action.yaml where {name-of-action} should be substituted with the name of the action. For example, if the tweeter action was only to be used internally, the path of the action metadata would be ./.github/tweeter/action.yaml:
The preceding action.yaml defines the following:
The name of the action that will be shown in the GitHub UI.
The author of the action.
The description of the action.
Branding that will be used for the action in the GitHub UI.
Input the action will accept.
Output the action will return.
The
runssection, which describes how the action will be executed.
In this example, we are using a Dockerfile, which will build a container from the Dockerfile and execute the container entry point with the specified arguments. Note how the inputs.sample context variable is used to map input to command-line arguments.
The preceding action can be executed with the following step:
The preceding sample execution does the following:
Executes a step using the sample action with the assumption that the action is tagged with
v1in thedevopsforgo/sample-actionrepository, withaction.yamlat the root of that repository, and specifies the required input variablesample.Echoes the
sampleOutputvariable.
Next, we will discuss how to tag action releases.
Action release management#
In all of our examples of using actions in our workflows, the uses: value for the action has always included the version of the action. For example, in the preceding sample, we used devopsforgo/sample-action@v1 to specify that we wanted to use the action at the Git tag of v1. By specifying that version, we are telling the workflow to use the action at the Git reference pointed to by that tag. By convention, the v1 tag of an action can point to any Git reference that is tagged in the semantic version range of v1.x.x. That means that the v1 tag is a floating tag and not static, and will advance as new releases in the v1.x.x range are released. Recall from the description of semantic versions earlier in this section that increments of the major version indicate breaking changes. The author of the action is making a promise to users that anything tagged with v1 will not include breaking changes.
The conventions used for versioning actions can cause friction when an action is included in the same repository as another versioned software project. It is advised to consider the implications of action versioning, and consider creating a repository dedicated to an action rather than creating it within a repository containing other versioned projects.
Goals for the tweeter custom GitHub Action#
In our custom GitHub Action for tweeter, we are going to accomplish the following:
Build a Dockerfile for building and running the tweeter command-line tool.
Create an action metadata file for the custom
action.Extendthe continuous integration job to test the action.Create an image release workflow for publishing the tweeter container image.
Optimize the tweeter custom action by using the published container image.
Next, we will create a custom Go action using a Dockerfile.
Creating the tweeter action#
With our goals for the tweeter custom action specified, we are ready to create the Dockerfile required to run tweeter, define the metadata for the action to map input and output from the tweeter command-line tool, extend our continuous integration job to test the action, and finally, optimize the start time for the action by using a pre-built container image in the custom action. We will break down each step and create our custom Go action.
Defining a Dockerfile#
The goal for the tweeter custom GitHub Action is building a Dockerfile for building and running the tweeter command-line tool.
Let's get started with a Dockerfile in the root of the tweeter repository that we will use to build a container image:
/
The preceding Dockerfile does the following:
Uses the
golang:1.17image as an intermediate builder container, which contains the Go build tools needed to compile the tweeter command-line tool. Using the builder pattern creates an intermediate container, containing build tools and source code that will not be needed in the end product. It allows us a scratch area to build a statically linked Go application that can be added to a slimmed-down container at the end of the build process. This enables the final container to only contain the Go application and nothing more.The build then copies in
go.modandgo.sum, and then downloads the Go dependencies for the tweeter application.The source for the tweeter application is copied into the builder container and then compiled as a statically linked binary.
The production image is created from the
gcr.io/distroless/static:latestbase image, and the tweeter application is copied from the intermediate builder container.Finally, the default entry point is set to the tweeter binary, which will enable us to run the container and directly execute the tweeter application.
The following is the code to build and run tweeter:
The preceding script does the following:
Builds the Dockerfile and tags it with the name
tweeter.Runs the tagged tweeter container image, passing the tweeter application the
-hargument, causing the tweeter application to print the help text.
Now that we have a working Dockerfile, we can use that to define a custom container action defined in action.yaml.
Creating action metadata#
The second goal for the tweeter custom GitHub Action is creating an action metadata file for the custom action.
Now that we have defined the Dockerfile, we can author a Docker action with the following action metadata in an action.yaml file in the root of the repository:
The preceding action metadata does the following:
Defines the action name, author, and description metadata.
Defines the expected input to the action.
Defines the output variable for the action.
Executes the Dockerfile, mapping the input of the action to the
argsof the tweeter application.
How the input variables map to the tweeter args command line is apparent due to the mapping of the input to the arguments, but it is not clear how the output variables are mapped. The output variables are mapped by specially encoding the variables in STDOUT in the Go application:
The preceding function prints to STDOUT the key and the message for an output variable. To return the sentMessage output variable, the Go application calls printOutput("sendMessage", message). The action runtime will read STDOUT, recognize the encoding, and then populate the context variable for steps.{action.id}.outputs.sentMessage.
With our action metadata defined, we are now ready to test our action by extending the tweeter continuous integration workflow to execute the action in the local repository
Testing the action#
The third goal of the tweeter custom GitHub Action is to extend the continuous integration job to test the action.
With the action.yaml file authored, we can add a workflow job to test the action:
The preceding test-action job does the following:
Checks out the code to the local workspace.
Executes the local action, specifying all required input and setting the
DRY_RUNenvironment variable totrueso that the action will not try to send the message to Twitter.Runs an
echocommand, fetching the echoed output from the action.
Let's see what happens when we trigger this workflow:
In the preceding screenshot, we can see that the test-action job is now part of the tweeter automation that will validate the action. Note the runtime of 54 seconds for executing the job. It seems like a long time to call a command-line application:
In the preceding screenshot, we can see that the test for the tweeter action took 49 seconds out of the total job runtime of 54 seconds. That is the vast majority of the time it took to execute the job. Most of that time was spent compiling tweeter and building the docker image prior to executing the action. In the next part, we'll optimize the action execution time by referencing a pre-built version of the tweeter container image.
Creating a container image release workflow#
The fourth goal of the tweeter custom GitHub Action is creating an image release workflow for publishing the tweeter container image.
As we saw in the previous section, the amount of time to build the Dockerfile was significant. There is little reason to do that for every execution of an action, which can be avoided by publishing the container image to a container registry and then using the registry image in place of the Dockerfile:
The preceding workflow definition does the following:
Triggers only when tags starting with
image-vare pushed.Requests permissions to write to the
ghcr.ioimage repository and read the Git repository.Contains a single container image build and steps to publish the image.
Checks out the repository.
Builds the
RELEASE_VERSIONenvironment variable based on the tag format.Sets up
buildxfor building the container image.Logs in to
ghcr.io, the GitHub container registry.Builds and pushes the container image tagged with both the release version and the latest version.
With the preceding workflow in place, we can tag the repository with the following commands and have the container image published to the GitHub container registry for use in the tweeter action:
Let's see the result of our image release workflow:
The preceding screenshot shows the release image workflow that was triggered by pushing the image-v1.0.0 tag. The following screenshot details the results of each step of the release image workflow:
The result of the preceding workflow is that we now have a container image pushed to ghcr.io/devopsforgo/tweeter, tagged with v1.0.0 and latest. We can now update the action metadata to use the tagged image version.
Optimizing the custom Go action#
The final goal of this lesson is optimizing the tweeter custom action by using the published container image.
Now that we have the image published to ghcr.io, we can replace the Dockerfile with the reference to the published image:
The preceding portion of the action.yaml file illustrates replacing the Dockerfile with the published tweeter container image. Now that the Dockerfile has been replaced, let's run the workflow and see the performance optimization in action:
The preceding screenshot illustrates the gains from using a pre-built container image. Recall, when using a Dockerfile, that the workflow execution was 54 seconds. Now, using the tweeter container image from the registry, the workflow executes in 6 seconds. This is a significant optimization and should be used when possible.
In this lesson, we learned to build custom actions using Go, which enables a DevOps engineer to build complex actions and package them in easily accessible units of automation. We also learned how to test and optimize these actions locally, ensuring that when custom actions are published, they function as intended.
Building a Release Workflow
Publishing a Custom Go GitHub Action